class: ou alias:, un choix de conteneur Symfony, et un choix d’ingĂ©nierie

Pourquoi un fake S3 jetable peut ĂȘtre prĂ©fĂ©rable Ă  MinIO

Deux dĂ©cisions se sont posĂ©es en recettant un pipeline d’import qui Ă©crit sur S3, sans le moindre accĂšs AWS en local.

La premiÚre est une question technique : pourquoi alias: fonctionne alors que class: échoue dans un override when@dev ?

La seconde est une dĂ©cision d’architecture : pourquoi choisir un double maison jetable plutĂŽt que MinIO ?

Le S3 n’est finalement qu’un prĂ©texte. Les deux rĂ©ponses se gĂ©nĂ©ralisent Ă  bien d’autres situations.

Le contexte

Une plateforme Symfony 5.3 importe des fichiers d’achats dĂ©posĂ©s par une centrale. Les lignes qui rĂ©fĂ©rencent une entitĂ© encore inconnue sont mises de cĂŽtĂ© dans un fichier de rejets sur S3 ; un Ă©cran d’administration permet ensuite de rĂ©soudre l’entitĂ©, puis de rĂ©importer ces lignes.

Le service qui parle Ă  S3, AwsService, tire ses credentials du rĂŽle de tĂąche ECS (CredentialProvider::ecsCredentials()), donc d’une infrastructure AWS uniquement disponible en production. Le nom du bucket vient quant Ă  lui d’une ligne de paramĂ©trage en base.

En local, avec DDEV : aucune variable AWS_*, aucun conteneur MinIO.

Tout appel Ă  read(), putPro() ou move() est donc vouĂ© Ă  l’échec. Pire : getBucket() renvoie null et le service Ă©choue silencieusement (return false, sans exception).

Une recette qui se contente de constater que « ça n’a pas plantĂ© » peut donc passer sans que rien ne se soit rĂ©ellement produit.

Il fallait exercer ce flux pour trois tickets successifs sur le mĂȘme pĂ©rimĂštre, Ă  chaque fois sur une implĂ©mentation prĂȘte mais pas encore livrĂ©e.

Deux solutions, toutes les deux valables

Deux approches étaient possibles.

  1. MinIO. Ajouter un conteneur S3-compatible au .ddev/config.yaml de l’équipe, et faire accepter Ă  AwsService un endpoint et des credentials statiques en environnement de dĂ©veloppement.

  2. Un double maison. Créer un fake de AwsService qui redirige les opérations vers le systÚme de fichiers local (var/fake-s3/), puis le brancher à la place du vrai service par un bloc when@dev non commité. Une fois la recette terminée, le double disparaßt.

Les deux sont valables.

Le choix n’est donc pas une question de faisabilitĂ© mais une question de contexte.

when@dev : substituer une implémentation pour un seul environnement

when@<env> (introduit dans Symfony 5.3) permet de scoper de la configuration à un environnement dans un fichier de configuration normal, plutÎt que dans un fichier séparé sous config/services/dev/.

Le bloc n’est pris en compte que lorsque kernel.environment correspond Ă  l’environnement <env> ciblĂ© et il est traitĂ© aprĂšs le corps principal du fichier. Il peut donc redĂ©finir des services existants.

En production, ce bloc n’est pas pris en compte : le conteneur de production ne contient aucune dĂ©finition issue de cet override.

C’est prĂ©cisĂ©ment ce qui en fait un bon rĂ©ceptacle pour un hack temporaire : tout l’override tient dans un bloc contigu et greppable, en fin d’un seul fichier.

On le voit facilement au git diff et le supprimer est trivial. De toute façon c’est pour un test, normalement on ne livre rien à la fin.

Le plus petit changement réversible possible.

# ─── TEMPORAIRE — recette locale, NE PAS COMMITER ───
when@dev:
    services:
        App\Service\AwsService:
            ...

class: contre alias: : redéfinir, ou simplement pointer ailleurs

Classiquement un override consiste à remplacer la classe utilisée par le service de test :

when@dev:
    services:
        App\Service\AwsService:
            class: App\Service\FakeAwsServiceForManualTesting

Dans le cas rencontré, cette configuration aboutit à une erreur au premier appel :

Fail

Too few arguments to function
App\Service\FakeAwsServiceForManualTesting::__construct(),
0 passed 
 and exactly 3 expected

Pourquoi ?

Il faut regarder comment le conteneur a été construit.

Le services.yaml par défaut contient notamment des _defaults (autowire: true, autoconfigure: true, bind:
) puis une ressource :

services:
    # default configuration for services in *this* file
    _defaults:
        autowire: true
        autoconfigure: true

    # makes classes in src/ available to be used as services
    App\:
        resource: '../src/'

    # ...

Cette ressource enregistre notamment App\Service\AwsService et App\Service\FakeAwsServiceForManualTesting comme services, avec leurs dĂ©finitions issues de l’auto-dĂ©couverte.

Mais une configuration placĂ©e sous when@dev.services constitue une nouvelle couche de configuration. Il faut donc ĂȘtre prudent lorsqu’on y redĂ©finit un service : on ne doit pas supposer que toute la configuration implicite de la dĂ©finition initiale sera conservĂ©e telle quelle.

Dans le cas rencontrĂ©, la redĂ©finition avec class: n’a pas conservĂ© la configuration nĂ©cessaire au constructeur du fake. Le service s’est retrouvĂ© avec une dĂ©finition qui ne savait plus rĂ©soudre ses trois dĂ©pendances.

On pourrait rendre cette redéfinition fonctionnelle en lui redonnant ce dont elle a besoin, par exemple avec autowire: true ou des arguments: explicites.

Mais ce n’est pas ce que l’on cherche ici, on ne veut pas redĂ©finir AwsService.
On veut dire :

« Quand quelqu’un demande AwsService en dĂ©veloppement, donne-lui plutĂŽt ce service-lĂ . »

C’est exactement le rîle d’un alias.

when@dev:
    services:
        App\Service\AwsService:
            alias: App\Service\FakeAwsServiceForManualTesting
            public: true

Un alias n’est pas une nouvelle dĂ©finition. C’est un pointeur : l’identifiant App\Service\AwsService se rĂ©sout vers le service enregistrĂ© sous App\Service\FakeAwsServiceForManualTesting.

La dĂ©finition du fake existe dĂ©jĂ  grĂące Ă  l’auto-dĂ©couverte de src/. Elle conserve donc sa propre configuration et son autowiring.

On ne rouvre aucune définition : on change simplement vers laquelle le conteneur pointe.

public: true est nĂ©cessaire ici parce qu’une commande console jetable va Ă©galement rĂ©cupĂ©rer le service par son identifiant.

En bref

class: sert à configurer une définition ; alias: sert à dire « à cet endroit, utilise cette autre implémentation ».

Ce n’est pas une rĂšgle absolue pour toutes les substitutions Symfony, mais c’est un excellent rĂ©flexe lorsqu’on dispose dĂ©jĂ  de deux services correctement dĂ©finis : si le besoin est simplement de faire pointer un identifiant vers une autre implĂ©mentation, l’alias est l’expression la plus Ă©troite de l’intention.

On ne reconstruit pas ce qui existe déjà. On change le pointeur.

Choisir en fonction du contexte, pas des capacités

MinIO est plus fidĂšle Ă  une infrastructure S3 rĂ©elle. Ça n’en fait pas le bon choix ici.
Les axes qui ont tranché sont les suivants.

Rayon d’impact

La solution MinIO modifie AwsService qui est déjà commité, partagé par toute la plateforme, présent dans tous les environnements, y compris les chemins S3 de production.

La solution avec le double ne touche aucun code commité : un fichier neuf et un bloc when@dev, tous deux jetés à la fin.

Le coĂ»t d’une erreur est donc bornĂ© par ce qu’on met en jeu.

Amortissement, ou sur-ingénierie

MinIO est un investissement.

Il devient intĂ©ressant si l’équipe doit rĂ©guliĂšrement tester des flux S3 en local. Le besoin rĂ©el ici Ă©tait beaucoup plus petit : trois tickets sur deux semaines, puis probablement plus rien avant des mois.

Sans rĂ©currence pour l’amortir, construire le harnais MinIO revient Ă  payer un coĂ»t d’infrastructure, de configuration et de maintenance pour une capacitĂ© que personne n’a demandĂ©e.

Le besoin est temporaire, la solution peut donc l’ĂȘtre aussi.

Demi-vie et réversibilité

Le hack a une demi-vie de quelques jours. Le retirer, c’est supprimer le bloc et le fake, puis Ă©ventuellement vider le cache.

La modification MinIO est permanente par construction. Or une chose permanente rĂ©clame un propriĂ©taire, de la documentation, une ligne d’onboarding et de la maintenance.

Une branche endpoint dans AwsService, que plus personne n’exerce quelques mois plus tard, est exactement le genre de code qui peut finir par accueillir un bug sans que personne ne s’en aperçoive.

Alignement des modes de défaillance

Le double échoue comme le comportement attendu par le métier :

fichier absent → lecture vide → badge « non importĂ© ».

Pas de faux vert.

MinIO, lui, peut Ă©chouer pour des raisons qui n’ont rien Ă  voir avec le mĂ©tier : mauvais bucket, mauvais endpoint, path-style, rĂ©gion, credentials


On se retrouve alors Ă  tester le harnais de test.

Fidélité consommée

La recette valide ici du métier :

  • le moteur de rĂ©import parse-t-il correctement le fichier de rejets ?
  • insĂšre-t-il les bonnes lignes ?
  • bascule-t-il correctement l’état ?

Ce mĂ©tier est indiffĂ©rent Ă  l’origine des octets.

Reproduire fidÚlement la sémantique S3 (cohérence, multipart, ACL, etc.) ajoute donc une fidélité que le test ne lit jamais.

La fidĂ©litĂ© qu’on ne consomme pas est un coĂ»t, pas une qualitĂ©.

Coût de communication

Un relecteur qui voit FakeAwsServiceForManualTesting et un bloc NE PAS COMMITER a tout compris en trente secondes.

MinIO demande davantage : une note de conception, une explication en Ă©quipe, une mise Ă  jour du guide d’onboarding, et potentiellement une rĂ©ponse Ă  la question :

« C’est quoi ce paramĂštre endpoint sur AwsService ? »

Et cela, pendant toute la durée de vie de la solution.

Ce que le double contient

Le double ne cherche pas Ă  reproduire S3.
Il reproduit uniquement la surface publique réellement appelée par le code sous test.

Les helpers privĂ©s (put, list, check) ne peuvent pas ĂȘtre surchargĂ©s ; on surcharge donc les wrappers publics utilisĂ©s par l’application :

class FakeAwsServiceForManualTesting extends AwsService
{
    private string $root;

    public function __construct(
        KernelInterface $kernel,
        LoggerInterface $logger,
        ParametersService $parametersService
    ) {
        parent::__construct($kernel, $logger, $parametersService);

        $this->root = $kernel->getProjectDir() . '/var/fake-s3';
    }

    public function read(
        string $app,
        string $filename,
        string $folder
    ): ?string {
        $path = "{$this->root}/" . trim($folder, '/') . "/$filename";

        return is_file($path)
            ? (file_get_contents($path) ?: '')
            : '';
    }

    public function putPro(
        string $filename,
        string $content,
        string $folder
    ): bool {
        $path = "{$this->root}/" . trim($folder, '/') . "/$filename";

        @mkdir(dirname($path), 0777, true);

        return file_put_contents($path, $content) !== false;
    }

    // putShop / putFTP / copy / delete* / list* :
    // mĂȘme forme, sur var/fake-s3/.

    // move() n'est pas surchargée :
    // la classe mĂšre la fait en copy()+delete().
}

Ce n’est pas un Ă©mulateur S3 et c’est volontaire. Le double implĂ©mente uniquement ce que le flux testĂ© consomme.

Tester le métier sans navigateur

Pour piloter les actions du contrĂŽleur sans passer par le navigateur, une commande console jetable injecte le mĂȘme service mĂ©tier que le contrĂŽleur.

Ce service est dĂ©sormais backĂ© par le fake grĂące Ă  l’alias.

La commande appelle donc les mĂȘmes mĂ©thodes que le parcours applicatif.

Tout le cahier de test peut se dĂ©rouler en CLI ; le navigateur ne sert plus qu’à confirmer l’UI.

C’est aussi ce qui rend dĂ©fendable l’argument :

On a validé le métier, pas S3.

Le principe

Deux rĂ©flexes de sĂ©nioritĂ© ressortent de cette expĂ©rience, l’un technique, l’autre architectural.

  • Pour substituer une implĂ©mentation, on ne redĂ©finit pas sa configuration si l’on peut simplement pointer vers une autre dĂ©finition existante. alias: exprime cette intention ; class: est Ă  utiliser lorsqu’on veut rĂ©ellement configurer ou redĂ©finir une dĂ©finition.
  • On choisit la solution dont le coĂ»t, humain autant que technique, est proportionnĂ© Ă  la taille et Ă  la durĂ©e de vie rĂ©elles du problĂšme : rayon d’impact, maintenance, onboarding, rĂ©versibilitĂ©, frĂ©quence d’utilisation.

Un jetable assumé bat souvent un permanent mal entretenu.

Et quand le contexte change, on refait le calcul.

Ici, le besoin Ă©tait temporaire. Le double l’était aussi.

Le signe que le choix Ă©tait bon est peut-ĂȘtre le plus concret : le pattern a Ă©tĂ© capturĂ© dans la base de connaissances de l’équipe, puis rĂ©utilisĂ© tel quel sur les deux tickets suivants.

Vingt minutes de mise en place, trois fois, contre un chantier de plusieurs jours.

Ce n’était pas la solution la plus complĂšte. C’était la solution proportionnĂ©e.